Note
Click here to download the full example code
unfolding¶
Unfold flattens a layer that undulates between two surfaces. it gives each point coordinates in the frame of the
layer: w from 0 on the footwall to 1 on the hanging wall, and (u, v) along it. variograms and kriging then run on
the unfolded coordinates, and the estimates go back to the real blocks row by row. the layer here is the saprolite of
a nickel laterite, between the bedrock and the limonite above it.
import sys
from pathlib import Path
HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import ACCENT, GRAY, HIGHLIGHT, INK, save
the bounding surfaces¶
448 vertical holes log four horizons from the top: ferricrete (FERR), limonite (LIM), saprolite (SAP) and
bedrock (BRK). the base of the saprolite is the bedrock contact, and its top the limonite contact. both are gridded
on 10 m cells: the bedrock elevation and the saprolite thickness by inverse distance, the top as their sum, so the
surfaces never cross. grid_surface turns each grid into a mesh.
data = bt.datasets.nickel_laterite_profile()
horizons = data["horizons"]
holes = bt.Drillholes.from_tables(data, intervals=None)
sap = horizons["HORIZON"] == "SAP"
ids = list(horizons["HOLE_ID"][sap])
top, base = horizons["FROM"][sap], horizons["TO"][sap]
contact = holes.at(ids, base)
grid = bt.BlockModel(origin=(29975, 59975, 0), size=(10, 10, 1), count=(106, 76, 1))
idw = bt.InverseDistance(bt.Search(radius=150, max_samples=12, min_samples=3))
bedrock_z = idw.fit(contact[:, :2], contact[:, 2]).predict(grid)
thickness = idw.fit(contact[:, :2], base - top).predict(grid)
bedrock = bt.grid_surface(grid.with_column("z", bedrock_z), "z")
limonite = bt.grid_surface(grid.with_column("z", bedrock_z + thickness), "z")
print(bedrock)
print(
f"bedrock {np.nanmin(bedrock_z):.0f} to {np.nanmax(bedrock_z):.0f} m, "
f"saprolite {np.nanmin(thickness):.1f} to {np.nanmax(thickness):.1f} m thick"
)
Out:
Mesh(8056 vertices, 15750 triangles, open, 360 boundary edges)
bedrock 293 to 365 m, saprolite 2.7 to 49.5 m thick
unfolded coordinates¶
Unfold(footwall, hangingwall) maps points to (u, v, w). by default u and v are the easting and northing and
w the relative position in the layer. mode="footwall" makes w the height above the footwall instead, and
reference="footwall" makes u and v arc lengths along it, which matters where the layer is steep. here the
bedrock is gentle and the arc lengths add a few percent over a kilometer. points outside the layer get NaN, unless
extrapolate=True. the logged horizons check the surfaces: the midpoints of the saprolite intervals fall in the
layer, and all but 1% of the others fall outside.
unfold = bt.Unfold(bedrock, limonite)
logged = bt.Drillholes.from_tables(data, intervals="horizons").samples()
inside = ~np.isnan(unfold.transform(logged)[:, 2])
is_sap = logged["HORIZON"] == "SAP"
print(
f"in the layer: {inside[is_sap].mean():.1%} of saprolite intervals, {inside[~is_sap].mean():.1%} of others"
)
assays = bt.Drillholes.from_tables(data).samples()
uvw = unfold.transform(assays)
keep = ~np.isnan(uvw[:, 2]) & ~np.isnan(assays["NI_PCT"])
assays, uvw = assays.filter(keep), uvw[keep]
xyz, ni, hole = assays.coords, assays["NI_PCT"], assays["HOLE_ID"]
print(f"{keep.sum()} assays in the saprolite, Ni {ni.mean():.2f} % mean")
along = bt.Unfold(bedrock, limonite, reference="footwall").transform(xyz)
print(
f"arc length along the bedrock minus plan distance: up to {np.max(along[:, 0] - xyz[:, 0]):.1f} m in u, "
f"{np.max(along[:, 1] - xyz[:, 1]):.1f} m in v"
)
Out:
in the layer: 100.0% of saprolite intervals, 1.0% of others
4894 assays in the saprolite, Ni 1.77 % mean
arc length along the bedrock minus plan distance: up to 45.6 m in u, 34.0 m in v
nickel is richer in the lower half of the saprolite. the bedrock moves up and down by tens of meters, so the profile
blurs against elevation. against w the decile means explain five times as much of the variance, though most of it
stays local.
def binned(x, n=10):
edges = np.quantile(x, np.linspace(0, 1, n + 1))
k = np.clip(np.searchsorted(edges, x, side="right") - 1, 0, n - 1)
means = np.array([ni[k == i].mean() for i in range(n)])
return (edges[1:] + edges[:-1]) / 2, means, 1 - np.var(ni - means[k]) / np.var(ni)
fig, axes = plt.subplots(1, 2, figsize=(8, 3.6), layout="constrained")
for ax, x, label in zip(axes, [xyz[:, 2], uvw[:, 2]], ["Elevation (m)", "w"], strict=True):
centers, means, share = binned(x)
ax.scatter(ni, x, s=2, color=GRAY, linewidths=0)
ax.plot(means, centers, color=HIGHLIGHT, lw=1.5)
ax.set(xlabel="Ni (%)", ylabel=label, title=f"Ni against {label.split()[0].lower()}")
print(f"{label.split()[0]:>9}: decile means explain {share:.1%} of the Ni variance")
save(fig, "profile")

Out:
Elevation: decile means explain 1.5% of the Ni variance
w: decile means explain 7.9% of the Ni variance
variograms¶
for variography, scale the unfolded w by the mean thickness so that distances across the layer stay in meters. along
the layer the pairs are horizontal in the real space and parallel to the surfaces in the unfolded one; across it, they
are vertical.
across the layer the two agree at the first lag, and the unfolded variogram rises faster: the holes are vertical, so
only the scaling of w changes the distances. along the layer the real variogram starts lower, 0.52 against 0.67 at
12 m: in this deposit nearby samples at one elevation are more alike than nearby samples at one position in the
profile.
depth = np.nanmean(thickness)
unfolded = uvw * [1, 1, depth]
spaces = {"real": xyz, "unfolded": unfolded}
fig, axes = plt.subplots(1, 2, figsize=(8, 3.4), layout="constrained")
for (name, coords), color in zip(spaces.items(), [GRAY, ACCENT], strict=True):
along = bt.experimental_variogram(coords, ni, 25.0, 300.0, azimuth=0, tolerance=90, bandwidth=1.0)
across = bt.experimental_variogram(coords, ni, 1.0, 10.0, azimuth=0, dip=90, tolerance=10, bandwidth=2.0)
for ax, e in zip(axes, [along, across], strict=True):
ax.plot(e.lags, e.gammas, "o-", color=color, ms=3, label=name)
print(
f"{name:>8}: gamma along {along.gammas[0]:.2f} at {along.lags[0]:.0f} m, "
f"across {across.gammas[0]:.2f} at {across.lags[0]:.1f} m"
)
for ax, title in zip(axes, ["Along the layer", "Across the layer"], strict=True):
ax.axhline(ni.var(), color=INK, lw=0.6, ls=":")
ax.set(xlabel="Lag (m)", ylabel="Variogram (%²)", title=title, ylim=(0, None))
axes[0].legend(loc="lower right")
save(fig, "variograms")

Out:
real: gamma along 0.52 at 12 m, across 0.37 at 0.5 m
unfolded: gamma along 0.67 at 12 m, across 0.37 at 0.5 m
kriging¶
the same ordinary kriging runs on both coordinate sets, cross-validated by leaving out one of ten groups of whole holes at a time.
the variograms predicted it: the real coordinates estimate slightly better, RMSE 0.816 % against 0.865 %. unfolding pays off where grade follows the layer, as in a folded bed or a profile whose base moves more than the grade changes across it. cross-validation on both coordinate sets tells you which case your deposit is.
model = bt.Variogram([("spherical", 0.6 * ni.var(), 150.0)], nugget=0.25 * ni.var(), ratios=(1.0, 0.05))
search = bt.Search(200.0, max_samples=24, ratios=(1.0, 0.05), max_per_hole=6)
kriging = {name: bt.OrdinaryKriging(model, search).fit(c, ni, holes=hole) for name, c in spaces.items()}
for name, k in kriging.items():
cv = k.cross_validate(folds=10)
print(f"{name:>8}: RMSE {cv.rmse:.3f} %, correlation {cv.correlation:.3f}")
Out:
real: RMSE 0.816 %, correlation 0.470
unfolded: RMSE 0.865 %, correlation 0.367
back to the blocks¶
the saprolite blocks are those whose centers unfold to a finite w. their unfolded centers are the kriging targets,
and the estimates, one per row, become a column of the real blocks; inverse maps the unfolded centers back onto the
centroids. in section the estimates run in bands parallel to the contacts.
blocks = bt.BlockModel(origin=(29975, 59975, 300), size=(10, 10, 1), count=(106, 76, 80))
blocks = blocks.filter(~np.isnan(unfold.transform(blocks)[:, 2]))
targets = unfold.transform(blocks)
back = unfold.inverse(targets)
print(f"inverse of the unfolded centers: {np.abs(back - blocks.coords).max():.1e} m from the centroids")
blocks = blocks.with_column("NI", kriging["unfolded"].predict(targets * [1, 1, depth]))
print(blocks)
north = 60300.0
plane = ((0, north, 0), 90, 90)
fig, ax = plt.subplots(figsize=(10, 3), layout="constrained")
bt.plot.section(blocks, "NI", plane=plane, resolution=1.0, ax=ax, vmin=0.5, vmax=3.0)
bt.plot.slab(np.empty((0, 3)), plane=plane, thickness=10, meshes=[bedrock, limonite], color=INK, ax=ax)
ax.set(title=f"Ni kriged in unfolded space, northing {north:.0f} m, 5x vertical", xlim=(30000, 31000))
ax.set_aspect(5)
save(fig, "section")

Out:
inverse of the unfolded centers: 0.0e+00 m from the centroids
BlockModel(masked, 87068 of 644480 cells, count [106, 76, 80], size [10.0, 10.0, 1.0], rotation [0.0, 0.0, 0.0])
NI: Float64
Total running time of the script: ( 0 minutes 0.704 seconds)
Download Python source code: example_04_09.py